iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0

前言

昨天把整個首頁的文字都接進 i18n 之後,我很得意地點了幾輪語言按鈕,看著標題、說明、按鈕文字一起變換,覺得自己真的很厲害。

然後我順手點了「進入紓壓診所」,切到英文模式之後再點一次。

呼吸過場的畫面跳出來,圓圈開始縮放,中間寫著:

吸氣...

英文模式,中文字。

我當場愣住,這跟直接被抓包講話不一致沒兩樣。整個沉浸感就這樣碎了一地。

回頭去翻程式碼,才發現問題出在哪。昨天我掃過了所有 data-i18n 的元素,但呼吸過場那段文字,根本不是用 innerText 直接塞進 HTML 的,而是寫死在一個計時器裡面:

function goToBreathe() {
    let step = 0;
    const breatheText = document.getElementById('breathe-text');
    const interval = setInterval(() => {
        step++;
        if (step === 1) breatheText.innerText = "呼氣...";
        if (step === 2) breatheText.innerText = "吸氣...";
        if (step >= 4) clearInterval(interval);
    }, 2000);
}

applyLanguage() 只掃了一次畫面上「當下存在」的文字,可是這個計時器是之後才動態寫進去的,昨天那套機制根本抓不到它。

所以今天的任務聽起來很小,做起來卻需要花點時間:

讓這個動態產生的文字也聽得懂語言切換,而且不能在動畫跑到一半的時候出包。


核心觀念:固定文字用「填表」,動態文字要用「查表」

昨天的做法,本質上是一次性填表:頁面載入的時候,把所有 data-i18n 元素掃過一輪,把對應語言的文字填進去,填完就結束了。

但呼吸動畫的文字不是一次性的,它是隨著時間不斷變化的:吸氣、吐氣、吸氣、吐氣,而且變化的當下,使用者隨時可能按下語言按鈕。

這種情況,不能再用「填表」的思維,而要改成「查表」。

意思是,不要在計時器裡直接寫死字串:

breatheText.innerText = "呼氣...";

而是把這句話也丟進 i18n 語言表裡:

breathIn: { zh: "吸氣...", en: "Breathe in...", ja: "吸って...", ko: "들이쉬고..." },
breathOut: { zh: "吐氣...", en: "Breathe out...", ja: "吐いて...", ko: "내쉬고..." }

計時器每次要更新文字的時候,不直接寫死中文,而是去查「現在是哪個語言,這個狀態該顯示什麼」:

breatheText.innerText = i18n.breathIn[currentLang];

這樣即使使用者在呼吸動畫跑到一半切換語言,下一次計時器觸發的時候,撈出來的就會是新語言的文字,不會卡在舊語言。

但這樣還不夠。如果使用者切換語言的那一瞬間,畫面上正顯示著一句話,而計時器還要等個一兩秒才會換下一句,這段空檔畫面上顯示的還是舊語言,一樣會露餡。

所以我還需要做一件事:applyLanguage() 被呼叫的當下,如果呼吸畫面正在顯示,要立刻把當下這句話也重新刷新一次,不等計時器自己換。

這代表我需要多記一個「目前呼吸動畫顯示的是吸氣還是吐氣」的狀態,讓 applyLanguage() 查得到。


動手寫 Code:會說四種語言的呼吸過場

今天把首頁跟呼吸過場接起來,重點是語言切換跟計時器怎麼互相配合:

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>賽博紓壓診所:呼吸過場的多語系同步</title>
<style>
    body {
        font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
        background: #e3f2fd;
        display: flex;
        justify-content: center;
        align-items: center;
        min-height: 100vh;
        margin: 0;
        padding: 20px;
    }
    .card {
        background: white;
        padding: 30px;
        border-radius: 20px;
        box-shadow: 0 12px 30px rgba(0,0,0,0.15);
        width: 100%;
        max-width: 460px;
        text-align: center;
    }
    .hidden { display: none; }

    .lang-switch {
        display: flex;
        justify-content: center;
        gap: 8px;
        margin-bottom: 20px;
    }
    .lang-btn {
        background-color: #f5f5f5;
        border: 2px solid transparent;
        border-radius: 50px;
        padding: 8px 16px;
        font-size: 0.85rem;
        cursor: pointer;
        color: #263238;
        transition: 0.2s;
    }
    .lang-btn:hover { background-color: #e3f2fd; }
    .lang-btn.active {
        background-color: #1e88e5;
        color: white;
        border-color: #1e88e5;
    }

    h1 { color: #1e88e5; margin: 0 0 10px; font-size: 1.4rem; }
    h2 { color: #263238; margin: 0 0 5px; font-size: 1.2rem; }
    p { color: #546e7a; font-size: 0.9rem; margin-bottom: 15px; }

    .btn {
        background-color: #1e88e5;
        color: white;
        border: none;
        border-radius: 50px;
        padding: 12px 30px;
        font-size: 1rem;
        cursor: pointer;
        width: 100%;
        margin-top: 15px;
        transition: 0.2s;
    }
    .btn:hover { background-color: #1565c0; transform: translateY(-2px); }

    .breathe-circle {
        width: 120px;
        height: 120px;
        background-color: #90caf9;
        border-radius: 50%;
        margin: 30px auto;
        display: flex;
        justify-content: center;
        align-items: center;
        color: white;
        font-weight: bold;
        animation: breatheAnimation 4s infinite ease-in-out;
    }
    @keyframes breatheAnimation {
        0% { transform: scale(1); background-color: #90caf9; }
        50% { transform: scale(1.3); background-color: #42a5f5; }
        100% { transform: scale(1.0); background-color: #90caf9; }
    }
</style>
</head>
<body>

    <div class="card">
        <div class="lang-switch">
            <button class="lang-btn" data-lang="zh">中文</button>
            <button class="lang-btn" data-lang="en">EN</button>
            <button class="lang-btn" data-lang="ja">日本語</button>
            <button class="lang-btn" data-lang="ko">한국어</button>
        </div>

        <!-- 首頁 -->
        <div id="welcome-screen">
            <h1 data-i18n="title"></h1>
            <p data-i18n="subtitle"></p>
            <button class="btn" data-i18n="enterBtn" onclick="goToBreathe()"></button>
        </div>

        <!-- 呼吸過場 -->
        <div id="breathe-screen" class="hidden">
            <h2 data-i18n="breatheTitle"></h2>
            <p data-i18n="breatheDesc"></p>
            <div class="breathe-circle" id="breathe-text"></div>
            <button class="btn" data-i18n="readyBtn" onclick="startQuizFlow()"></button>
        </div>
    </div>

    <script>
        const i18n = {
            title: {
                zh: "賽博紓壓診所與解碼器", en: "Cyber Stress Clinic",
                ja: "サイバー・ストレスクリニック", ko: "사이버 스트레스 클리닉"
            },
            subtitle: {
                zh: "結合廟宇抽籤、雷達圖分析與惡搞處方籤的頂級心靈小工具",
                en: "Fortune draws, radar charts, and absurd prescriptions, all in one app",
                ja: "おみくじ、レーダーチャート、ふざけた処方箋が詰まった癒しツール",
                ko: "운세뽑기, 레이더 차트, 엉뚱한 처방전이 담긴 힐링 도구"
            },
            enterBtn: {
                zh: "進入紓壓診所", en: "Enter the Clinic",
                ja: "クリニックへ入る", ko: "클리닉 입장하기"
            },
            breatheTitle: {
                zh: "🧘 調整呼吸,放鬆心情", en: "🧘 Take a Breath, Relax",
                ja: "🧘 呼吸を整えて、心を落ち着けよう", ko: "🧘 숨을 고르고 마음을 편안하게"
            },
            breatheDesc: {
                zh: "請跟隨圓縮放的節奏,深吸氣... 吐氣...",
                en: "Follow the circle's rhythm — breathe in... breathe out...",
                ja: "円の動きに合わせて、深く吸って...吐いて...",
                ko: "원의 리듬에 맞춰 깊게 들이쉬고... 내쉬세요..."
            },
            // 今天的重點:動態文字也要進語言表
            breathIn: { zh: "吸氣...", en: "Breathe in...", ja: "吸って...", ko: "들이쉬고..." },
            breathOut: { zh: "吐氣...", en: "Breathe out...", ja: "吐いて...", ko: "내쉬고..." },
            readyBtn: {
                zh: "準備好了,開始解碼", en: "I'm Ready, Let's Start",
                ja: "準備完了、始めよう", ko: "준비됐어요, 시작할게요"
            }
        };

        let currentLang = "zh";
        let breathState = "in"; // 記錄呼吸動畫目前顯示的是哪一句,讓切換語言時能立刻刷新
        let breatheInterval = null;

        // ---------- 固定文字:一次性填表(Day 19 的做法) ----------
        function fillStaticText(lang) {
            document.querySelectorAll("[data-i18n]").forEach(el => {
                const entry = i18n[el.dataset.i18n];
                if (entry && entry[lang]) el.innerText = entry[lang];
            });
        }

        // ---------- 動態文字:查表,而且要能立刻補刷新 ----------
        function refreshBreatheText() {
            const el = document.getElementById("breathe-text");
            const key = breathState === "in" ? "breathIn" : "breathOut";
            el.innerText = i18n[key][currentLang];
        }

        function applyLanguage(lang) {
            currentLang = lang;
            fillStaticText(lang);

            // 如果呼吸畫面正顯示著,語言一換就立刻補刷新,不等計時器
            if (!document.getElementById("breathe-screen").classList.contains("hidden")) {
                refreshBreatheText();
            }

            document.querySelectorAll(".lang-btn").forEach(btn => {
                btn.classList.toggle("active", btn.dataset.lang === lang);
            });
            document.documentElement.lang =
                lang === "zh" ? "zh-Hant" : lang === "ja" ? "ja" : lang === "ko" ? "ko" : "en";
        }

        document.querySelectorAll(".lang-btn").forEach(btn => {
            btn.addEventListener("click", () => applyLanguage(btn.dataset.lang));
        });

        function showScreen(id) {
            document.querySelectorAll("#welcome-screen, #breathe-screen").forEach(el => el.classList.add("hidden"));
            document.getElementById(id).classList.remove("hidden");
        }

        // ---------- 呼吸過場:狀態查表,不再寫死字串 ----------
        function goToBreathe() {
            showScreen("breathe-screen");

            breathState = "in";
            refreshBreatheText();

            let step = 0;
            if (breatheInterval) clearInterval(breatheInterval);

            breatheInterval = setInterval(() => {
                step++;
                breathState = step % 2 === 1 ? "out" : "in";
                refreshBreatheText();
                if (step >= 4) clearInterval(breatheInterval);
            }, 2000);
        }

        function startQuizFlow() {
            if (breatheInterval) clearInterval(breatheInterval);
            alert(currentLang === "zh" ? "準備開始測驗(示範到此為止)" : "Starting the quiz (demo ends here)");
            showScreen("welcome-screen");
        }

        applyLanguage("zh");
    </script>
</body>
</html>

再存成 .html 打開,點「進入紓壓診所」,趁呼吸圓圈還在跑的時候,故意切一次語言,畫面上的字會立刻跟著變,不用等到下一次計時器觸發。


幾個我踩過的小細節

1. 為什麼不能只靠 fillStaticText() 掃一次就解決?

因為 fillStaticText() 掃的是現在畫面上存在的 data-i18n 元素,而呼吸文字是計時器之後才動態寫進去的內容,不是透過這套機制填進去的。就算呼吸畫面的容器有掛 data-i18n,掃到的也只會是「容器當下顯示的那句話」,不會變成一個會隨狀態改變的查表邏輯。這也是我昨天完全沒發現這個破口的原因,因為首頁那些固定文字用這招完全夠用。

2. 為什麼要額外記一個 breathState 變數?

一開始我想說,語言切換的時候直接重新跑一次計時器邏輯不就好了?但這樣會打斷使用者正在看的呼吸節奏,圓圈動畫跟文字會突然跳掉,體驗更糟。

所以我選擇只記錄「現在該顯示哪一句」這個狀態,語言切換的時候只重新查表、重新渲染文字,完全不動計時器跟圓圈動畫本身的進度。文字換了,呼吸的節奏不會被打斷。

3. if (breatheInterval) clearInterval(breatheInterval) 這行是幹嘛的?

這是防止使用者連續點很多次「進入紓壓診所」,結果同時跑出好幾個計時器,互相搶著更新畫面,最後文字變化變得忽快忽慢,很詭異。每次開始呼吸過場之前,先把前一個計時器清掉,確保畫面上同時只有一個計時器在跑。

4. 為什麼呼吸文字、按鈕文字都走查表,示範裡的 alert 卻用了一個 if-else?

因為那個 alert 只是今天示範用的收尾訊息,正式診所裡根本不會有這段。如果連示範用的臨時訊息都要正式加進 i18n 表,反而會讓語言表塞進一堆之後要刪掉的東西。這算是我刻意偷懶的地方,讀者如果把這段接回正式專題,記得把 alert 換成真正的測驗流程。


開發心得與設計考量

今天讓我重新想了一下「呼吸過場」這件事到底在做什麼。

昨天做完首頁切換的時候,我以為自己已經把 i18n 這個概念搞懂了:不就是把文字抽出來,掛個標記,寫個函式去填嗎?

結果今天一踩雷才發現,固定文字跟動態文字,是兩種完全不同的題目。固定文字只要「填一次」,動態文字卻要考慮「狀態什麼時候變、使用者什麼時候會在中途打斷它」。

這也讓我想到,之前做計分系統的時候也遇過類似的狀況:Day 11 先處理「分數怎麼收集」,Day 17 才處理「分數怎麼公平比較」,表面上做的都是「分數」,但其實是完全不同層次的問題。今天也是一樣,表面上都是「i18n」,但「填表」跟「查表」是不同的設計思路,不能用同一套邏輯打發。

如果之後還要讓求籤結果、處方籤內容也支援多語言,大概也得照這個邏輯去想:這段文字是「畫面一載入就固定」,還是「會隨著使用者操作動態產生」,兩種要用不同的策略處理,硬套同一招遲早會出包。


今日結語與明日預告

今天我們把呼吸過場那段原本寫死在計時器裡的文字,改成跟語言表查詢,還額外記錄了呼吸狀態,讓使用者就算在動畫跑到一半切換語言,畫面也能立刻跟上,不會露出馬腳。

順便也讓我重新體會到,i18n 不是做一次就結束的事,而是之後每加一個新功能,都要多想一步「這段文字是固定的,還是會動態變化的」。

不過寫完這篇,我盯著呼吸過場那個單調的圓圈動畫看了一陣子,突然覺得有點不滿足。

這個圓圈,從頭到尾只是單純地縮放,看起來比較像呼吸,不太像「正在監測」。

既然都叫「賽博診所」了,病人躺上診療台的時候,旁邊怎麼能沒有一台會跳動的心電圖呢?那種滴、滴、滴,螢幕上一條綠線忽高忽低跑過去的畫面,才是真正的賽博感。

所以明天 Day 21,我們要先放下語言跟文字,回頭做一個純視覺的東西:打造一個即時心電圖(ECG)模擬器,讓波形隨著測驗進度條即時跳動,讓整個診所的儀式感再往上升一級。

讓診所裝上一台會呼吸的心電圖機。我們明天見!


上一篇
Day 19:多國語系支援(i18n):實作中、英、韓、日四語即時動態切換
下一篇
Day 21:幫診所裝一台會跳動的心電圖機
系列文
賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言